Skip to content

Add a nushell hook - #8

Open
edaywalid wants to merge 8 commits into
mainfrom
feat/nushell-hook
Open

Add a nushell hook#8
edaywalid wants to merge 8 commits into
mainfrom
feat/nushell-hook

Conversation

@edaywalid

Copy link
Copy Markdown
Owner

Ports undo.fish to nushell, for the open request to support it.

Same shape as the other hooks. pre_execution opens the session
directory, writes cmd and pid, and puts the shim at the front of
LD_PRELOAD after dropping any other libundo.so. pre_prompt writes
the done marker, restores the previous LD_PRELOAD, and runs the gc.

Two things nushell does differently:

  • No %N in format date, so the session id comes from date now | into int, which is already nanoseconds.
  • A hook cannot assign $env directly and have it stick. Only load-env
    and hide-env reach the caller, so the arming and disarming go through
    those, and the hook bodies are blocks rather than closures because that
    is the form that preserves the environment.

What I checked

  • Parses and loads on nushell 0.114.1.
  • Registers both hooks and sets UNDO_HOOK=nu.
  • The pre_execution body builds a correct session: 16 character id,
    cmd, pid and data/ all present.

What I could not check

The full arm and disarm cycle in a live shell. Nushell's line editor
takes input from a pty but never executes it, so I could not drive a real
session from a script, with script(1), expect, or a pty driver. That
also means this hook is not in the CI smoke tests that now cover zsh,
bash and fish.

So this needs someone with nushell to confirm the part that matters:

source /path/to/undo.nu
rm somefile
undo

undo should list the rm and put the file back. If it says "nothing to
undo", the hook is not arming and this is not ready.

Left out of this PR on purpose: the installer, the README and the
packaging still do not mention nushell. Nothing should advertise the hook
until the above is confirmed.

Ports undo.fish to nushell. Same shape: pre_execution opens the session
and arms LD_PRELOAD, pre_prompt writes the done marker, restores the
previous LD_PRELOAD and runs the gc.

Two things differ from the other shells. Nushell has no %N in format
date, so the session id comes from an int date, which is already
nanoseconds. And a hook cannot set $env directly, since only what goes
through load-env and hide-env reaches the caller, so the arming is
written in terms of those.

Not verified end to end. See the PR for what was checked and what was
not.
nushell runs rm, mv, cp, mkdir and save inside its own process. There is
no child for LD_PRELOAD to attach to, so the shim has to live in nu
itself, which zsh already does under UNDO_CAPTURE_SHELL. The part zsh's
approach cannot cover is telling the shim which session it is in:
`$env.X = ...` only builds the environment handed to processes nu
starts, and never touches the running nu, which is the process holding
the shim. Set the session that way and the shim reads a variable nushell
never set.

So the nu hook exports UNDO_SESSION_PTR once, before the exec that loads
the shim, and rewrites the file it names before every command.
session_dir() falls back to reading it when UNDO_SESSION is absent.

The cost is confined. Every other shell exports UNDO_SESSION and returns
on the line above, having paid one getenv that finds nothing. Nushell
pays one pread per intercepted call, from a descriptor held open on a
page-cached file, which is the price of recording anything there at all.

The descriptor is released by the same TLS destructor as the journal and
the budget mapping, so this does not reintroduce the per-thread leak.
Driven under a pty, the hook as it stood recorded nothing at all. `rm
file`, `undo`, and the file was gone: nushell's rm is a built-in, so no
process is ever exec'd and the shim armed around child processes has
nothing to attach to. Empty sessions, then gc removing them.

The hook now re-execs nu once with the shim preloaded and hands the
session over through UNDO_SESSION_PTR. Everything the shim reads is
settled before that exec, because the exec is what turns $env into a
real environment.

Riding inside nu has two consequences worth naming:

Nushell writes files for itself. Left alone every session recorded nu
rewriting its own history, so `undo` after a plain `ls` offered to put
an older history file back. Its config, data and cache directories join
the ignore list, and so does the store, since the hooks rewrite the
session pointer while the shim is still armed.

`cp` cannot be caught at all. It goes straight to the kernel rather than
through libc, which I confirmed by tracing every libc open during a copy
and finding none, so a copy over an existing file destroyed the target
and journaled nothing. `alias cp = ^cp` hands the name to coreutils,
whose flags are a superset. It has to sit at the top level: an alias
declared inside the `if` guard never leaves it.

Also fixed from review: the done marker is written only if the session
directory still exists, a degraded session is reported at the prompt,
_UNDO_PREV_PRELOAD is gone rather than exported to every child, and the
gc fallback no longer deletes directories from inside a `where`.
hook.sh feeds the other shells from a pipe. nushell cannot be tested that
way: reedline asks the terminal for the cursor position and blocks until
something answers, so nu loops on the prompt and drops every command
fed to it. That is why this hook shipped unrun. pty-drive.py answers the
query, which is enough to make hooks fire.

The nu case asserts the coverage undo.nu depends on rather than a single
smoke path, because all of it rests on nushell internals that a release
could change without warning: built-in rm records an unlink, cp over an
existing file records a mod, save over an existing file records a mod
and undo puts it back. It also asserts nushell's own history is never
journaled.

Verified the assertions fail without the code that satisfies them:
removing the cp alias fails the cp case.
Nothing installed undo.nu, so the file reached no one: not the Makefile,
not install.sh, not the deb, rpm or Arch packages. Homebrew already
globbed shell/*.

install.sh prepends the source line for nushell rather than appending
it. undo.nu re-execs nu, so config above that line runs a second time,
and a config that prepends to PATH would do it twice. For the same
reason nushell gets no PATH line of its own.

The README says plainly which nushell commands are recorded, that cp is
covered only through the alias, and why the line belongs at the top of
config.nu.
test/hook.sh skips itself when its shell is missing, which is why the
workflow already installs zsh and fish before running the suite rather
than after. nushell is not in apt, so it comes from the release tarball.

The version is pinned on purpose. This test asserts what nushell's
built-ins do, so an unpinned upgrade would land a nushell change as a
mystery failure on somebody else's PR. Bumping the pin is how we choose
to find out.
install.sh installs from the latest release, and v0.3.0 shipped before
the nushell hook existed, so unconditionally installing shell/undo.nu
killed the installer outright for every shell.

Install it when the tarball has it. The condition stops mattering once a
release carries the file, and costs nothing after that.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant